가변 길이 구조체(Struct Hack)와 필러 필드 — 헤더 뒤에 매달리는 가변 payload
NOTE
헤더(고정 크기 메타데이터)와 가변 길이 바디(payload)를 하나의 연속된 메모리 블록으로 표현하는 struct hack 관용구와, 그 표준화된 버전인 C99 유연 배열 멤버(flexible array member)를 정리한다. 구조체 크기를 인위적으로 고정해두는 필러(filler) 필드 관례도 함께 다룬다.
0. 왜 필요한가
메시지 하나를 “고정 크기 헤더 + 길이가 매번 다른 바디”로 표현해야 하는 경우가 흔하다. 헤더와 바디를 서로 다른 두 개의 malloc으로 나눠 관리할 수도 있지만, 그러면 메시지를 통째로 한 번에 복사하거나 소켓으로 보낼 때 포인터를 두 번 따라가야 해서 번거롭다. 헤더와 바디를 하나의 연속된 메모리 블록으로 만들고 싶다는 요구에서 나온 관용구가 struct hack이다.
1. 기본형 — 크기 1(또는 0)짜리 트레일링 배열
typedef struct {
unsigned int payload_len; /* 실제 페이로드 길이 */
int msg_type;
char data[1]; /* 실제로는 이보다 훨씬 길게 할당된다 — "데이터 시작 지점" 표시일 뿐 */
} msg_t;data[1]은 진짜로 1바이트짜리 배열을 뜻하는 게 아니라, “이 구조체 몸통이 끝나는 지점, 즉 페이로드가 시작하는 주소”를 가리키는 이름표로 쓰인다.
2. 실제로 할당하고 채우는 법
#include <stdlib.h>
#include <string.h>
msg_t *msg_create(const char *payload, unsigned int len)
{
msg_t *msg = malloc(sizeof(msg_t) + len); /* 헤더 크기 + 실제 데이터 길이만큼 넉넉히 할당 */
if (msg == NULL) {
return NULL;
}
msg->payload_len = len;
msg->msg_type = 1;
memcpy(msg->data, payload, len); /* data는 1바이트로 선언돼 있지만 len바이트를 써도 할당받은 범위 안이다 */
return msg;
}컴파일러는 data를 1바이트짜리 배열로만 알고 있지만, msg_create가 실제로 그보다 훨씬 큰 메모리를 할당했기 때문에 data[0]부터 data[len-1]까지 접근해도 할당받은 메모리 범위를 벗어나지 않는다. 이건 C 언어가 공식으로 보장하는 동작이 아니라 관행적으로 통하는 트릭이라, 배열 경계를 검사하는 정적 분석 도구가 여기서 종종 (거짓) 경고를 낸다.
3. C99 유연 배열 멤버(Flexible Array Member) — 표준화된 버전
typedef struct {
unsigned int payload_len;
int msg_type;
char data[]; /* 크기를 아예 안 씀 — C99가 정식으로 인정한 "유연 배열 멤버" */
} msg_t;
msg_t *msg = malloc(sizeof(msg_t) + len); /* sizeof(msg_t)가 data 크기를 전혀 포함하지 않는다 */[1]/[0]로 쓰던 예전 스타일과 달리, [](크기 생략)는 C99부터 언어가 공식으로 정의한 문법이다. 가장 큰 차이는 sizeof(msg_t)가 data 필드의 크기를 아예 포함하지 않는다는 점이다 — [1] 스타일은 1바이트가 이미 포함돼 있어 할당 크기 계산 시 이 1바이트를 신경 써야 하지만, [] 스타일은 그럴 필요가 없다.
4. 왜 레거시 코드엔 여전히 [1] 스타일이 많은가
C99 이전(또는 C99를 완전히 지원하지 않던 컴파일러) 시절부터 굳어진 관행이기도 하고, 이미 그 크기 계산 방식(sizeof(msg_t) + len)에 맞춰 짜인 코드가 곳곳에 퍼진 상태에서 필드 선언만 []로 바꾸면 sizeof 값이 달라져 관련된 모든 malloc 크기 계산 코드를 다시 점검해야 한다. 그래서 오래된 코드베이스는 새 구조체가 아니면 [1] 스타일을 그대로 유지하는 경우가 많다.
5. 필러(filler) 필드 — 구조체 크기를 인위적으로 고정해두는 관례
typedef struct {
int n_value;
#if 1 /* 필드 하나를 새로 추가하면서 sizeof(struct)는 그대로 유지하고 싶을 때 */
int n_new_field;
char sz_filler[4]; /* 새 필드가 차지한 만큼 필러를 줄여서 전체 크기를 맞춘다 */
#else
char sz_filler[8]; /* 필드 추가 전에는 필러가 더 컸다 */
#endif
} record_t;구조체를 그대로 IPC로 주고받거나 파일에 직렬화하는 코드에서는 구조체의 메모리 레이아웃 자체가 통신 규격이 된다(실무 C 코드 관례와 UB 함정 정리의 B12). 필드를 추가/제거할 때마다 이 구조체를 쓰는 모든 프로세스·바이너리를 한꺼번에 다시 빌드할 수 있다면 문제없지만, 그게 어려운 상황(구버전과의 호환 유지, 배포 시점이 서로 다른 여러 프로세스)이라면 여분 공간을 미리 확보해두고, 새 필드를 넣을 때마다 그 여분을 깎아 쓰는 방식으로 sizeof(record_t) 자체를 고정해 하위 호환성을 지킨다.
6. 자주 하는 실수
- malloc 크기 계산에서 트레일링 배열 크기를 혼동하기 —
[1]스타일은sizeof(msg_t)에 이미 1바이트가 포함돼 있으므로malloc(sizeof(msg_t) + len)은 사실 1바이트를 더 여유 있게 할당하는 셈이다(대개 무해하지만, 정확히 맞추려면sizeof(msg_t) - 1 + len으로 계산하는 코드도 있다).[]스타일은 이 보정이 필요 없다. 두 스타일의 계산 공식을 섞어 쓰면 한쪽 기준으로 짠 코드를 다른 쪽에 그대로 옮겼을 때 오프바이원 버그가 생긴다. - 이 구조체를 값으로 복사하거나 배열/지역 변수로 선언하기 —
msg_t m = *ptr;이나msg_t arr[10];처럼 쓰면, 컴파일러가 아는 크기(헤더 + 트레일링 배열의 선언된 크기)만큼만 복사·할당되어 실제 페이로드는 통째로 잘려나간다. 이런 구조체는 항상 포인터로만 다루고 반드시 힙에만 존재해야 한다. - 유연 배열 멤버(
data[]) 뒤에 다른 필드를 또 선언하기 — 표준상 유연 배열 멤버는 반드시 구조체의 마지막 멤버여야 한다. 그 뒤에 필드를 추가하면 컴파일 에러다. - 필러 필드의 크기를 계산 없이 대충 줄이기 — 필러는 “새로 추가한 필드가 차지한 바이트 수만큼 정확히” 줄여야 전체 구조체 크기가 유지된다. 얼마나 줄여야 하는지 계산하지 않고 대충 줄이면, 그 구조체를 공유하는 다른 쪽(예전 빌드, 다른 프로세스)과 레이아웃이 미묘하게 어긋나 특정 필드만 쓰레기 값으로 읽히는 디버깅하기 어려운 문제가 생긴다.
7. 실전 사례 — length + 가변 바이트 (가변 바이너리 타입)
DB·통신 라이브러리가 제공하는 가변 길이 바이너리 타입(OCTETS, BLOB 계열)은 대개 이 패턴으로 만들어져 있다. 필드 값이 length와 그 뒤에 이어 붙는 바이트 열 하나로 표현된다.
typedef struct octets {
int length; /* 실제 데이터 바이트 수 */
char data[1]; /* flexible array — 데이터 시작 위치 표시 (선언 크기 1, 실제는 length 바이트) */
} octets_t;왜 길이 필드가 필수인가
char *문자열은 끝에\0이 있어strlen으로 길이를 알 수 있다.- 바이너리는 값 중간에
0x00이 있을 수 있고 끝 표시도 없다 →length가 유일한 길이 정보다.strlen·strcpy·%s를 쓰면 안 되고memcpy/length기준 루프를 쓴다.
값 추가 (생성)
octets_t *o;
size_t n = 4;
o = malloc(offsetof(octets_t, data) + n); /* 헤더(length) + 데이터 n바이트 */
if (o == NULL) { /* error */ }
o->length = n; /* length를 반드시 먼저 채운다 */
o->data[0] = 0x11;
o->data[1] = 0x22;
o->data[2] = 0x33;
o->data[3] = 0xFF;
/* 또는: memcpy(o->data, src, n); */
/* ... 라이브러리 호출에 o를 넘긴다 ... */
free(o);- 할당 크기 =
length필드 크기 + 데이터 n바이트.malloc(sizeof(int) + n)으로도 쓰지만,offsetof(octets_t, data) + n이 같은 값을 의도가 드러나게 표현한다. sizeof(octets_t) + n은[1]스타일에서 패딩 포함 8바이트(x86-64)라 4바이트가 더 할당된다 (동작은 하지만 낭비 — 위 6번 항목의 “계산 공식 혼동” 참고).- 값을 읽는 쪽은
length만 보고 몇 바이트를 가져갈지 정하므로length가 틀리면 잘리거나 쓰레기가 섞인다.
값 접근 (읽기)
int j;
printf("len=%d 0x", o->length);
for (j = 0; j < o->length; j++) {
printf("%02x", o->data[j] & 0xff); /* & 0xff — char 부호 확장 방지 */
}| 접근 | 방법 |
|---|---|
| 길이 | o->length |
| i번째 바이트 | o->data[i] (0 ≤ i < length) |
| 전체 복사 | memcpy(dst, o->data, o->length) (dst는 length 이상 확보) |
| 구조체째 복사 | memcpy(dst, o, offsetof(octets_t, data) + o->length) — *dst = *o 값 복사는 금지 |
| 16진 출력 | %02x + & 0xff (char가 signed면 0x80 이상이 ffffff..로 출력됨) |
- 라이브러리가 돌려준 포인터가 내부 버퍼(결과집합 등)를 가리키는 경우가 많으므로, 수정이 필요하면 다른 버퍼에 복사한 뒤 수정한다.
- 길이가 스키마로 고정된 바이너리(
BINARY(N)류)는 이 구조체 없이char *와 N으로 다루는 경우가 많다. - 구조체 배열이 이어 붙는 구조(요소 반복)는 버퍼를 구조체로 캐스팅해 읽기를 참고.